Skip to content

feat(inspector): attach Chrome DevTools to Web Worker isolates#1973

Open
edusperoni wants to merge 2 commits into
mainfrom
feat/worker-inspector
Open

feat(inspector): attach Chrome DevTools to Web Worker isolates#1973
edusperoni wants to merge 2 commits into
mainfrom
feat/worker-inspector

Conversation

@edusperoni

@edusperoni edusperoni commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Workers were invisible to the debugger: the inspector only served the main isolate and even dropped worker console output on the floor. DevTools discovers extra isolates through the Target domain with flat-session multiplexing, so implement that surface in the runtime:

  • Add WorkerInspectorClient: a per-worker V8Inspector + session that lives entirely on the worker's thread. Incoming CDP messages queue through an eventfd on the worker's ALooper (the same mechanism as the worker message inbox); while paused at a breakpoint a nested loop on the worker thread pumps the same queue without re-entering the looper, so postMessage deliveries stay queued during a pause, matching Chrome's semantics.
  • Turn JsV8InspectorClient into the root session + router: handle Target.setAutoAttach natively on the socket thread (announce workers via Target.attachedToTarget, detach on death), route messages carrying a top-level sessionId to the worker's thread directly from the socket thread, and make the frontend sender thread-safe. Routing off the socket thread means a worker stays debuggable while the main isolate is paused, and vice versa.
  • Debugger.pause for a busy worker uses RequestInterrupt keyed by workerId, so a late-firing interrupt after worker death is a no-op; it is skipped while that worker is already paused, avoiding a spurious re-pause after resume (same guard as the main isolate).
  • Serve Network.loadNetworkResource and IO.read/IO.close on the socket thread for any session (sessionId echoed), so worker source maps load too, and apply the nsruntime:// sourceMapURL rewrite to worker Debugger.scriptParsed events.
  • Route worker console.* to the worker's own inspector and deliver through V8ConsoleMessageStorage::addMessage instead of the live-only runtime agent path: messages logged before the frontend attaches are stored and replayed as console history. Applies to the main isolate as well.
  • Name execution contexts ("main" / worker script url) so the DevTools console context selector rows are labeled and selectable.
  • Worker lifecycle: the inspector is created on the worker thread before the script runs (debug builds only), terminate() kicks a paused worker out of its nested pause loop, teardown unregisters the target before the isolate is disposed, and frontend reconnects reset worker sessions (ordered before the main-isolate Locker so workers recover even if the main isolate is paused).
  • Make isConnected_ atomic: worker threads now read it while the main thread writes the adjacent bitfield flags.

waitForDebuggerOnStart (pause new workers before their first line) is left as future work; workers announce waitingForDebugger: false.

Ports NativeScript/ios#386 to Android.

Summary by CodeRabbit

  • New Features
    • Added worker-side V8 inspector support with DevTools-ready session/target routing.
    • Worker console output is now forwarded into the inspector for visibility.
  • Bug Fixes
    • Improved socket-thread message handling to send only when a non-empty response is produced by the fast path.
    • Strengthened thread-safety for inspector connections and singleton initialization.
    • Refined pause/reset behavior and worker session acknowledgment handling.
  • Chores
    • Enabled additional inspector sources for debug builds.

Workers were invisible to the debugger: the inspector only served the
main isolate and even dropped worker console output on the floor.
DevTools discovers extra isolates through the Target domain with
flat-session multiplexing, so implement that surface in the runtime:

- Add WorkerInspectorClient: a per-worker V8Inspector + session that
  lives entirely on the worker's thread. Incoming CDP messages queue
  through an eventfd on the worker's ALooper (the same mechanism as the
  worker message inbox); while paused at a breakpoint a nested loop on
  the worker thread pumps the same queue without re-entering the
  looper, so postMessage deliveries stay queued during a pause,
  matching Chrome's semantics.
- Turn JsV8InspectorClient into the root session + router: handle
  Target.setAutoAttach natively on the socket thread (announce workers
  via Target.attachedToTarget, detach on death), route messages
  carrying a top-level sessionId to the worker's thread directly from
  the socket thread, and make the frontend sender thread-safe. Routing
  off the socket thread means a worker stays debuggable while the main
  isolate is paused, and vice versa.
- Debugger.pause for a busy worker uses RequestInterrupt keyed by
  workerId, so a late-firing interrupt after worker death is a no-op;
  it is skipped while that worker is already paused, avoiding a
  spurious re-pause after resume (same guard as the main isolate).
- Serve Network.loadNetworkResource and IO.read/IO.close on the socket
  thread for any session (sessionId echoed), so worker source maps
  load too, and apply the nsruntime:// sourceMapURL rewrite to worker
  Debugger.scriptParsed events.
- Route worker console.* to the worker's own inspector and deliver
  through V8ConsoleMessageStorage::addMessage instead of the live-only
  runtime agent path: messages logged before the frontend attaches are
  stored and replayed as console history. Applies to the main isolate
  as well.
- Name execution contexts ("main" / worker script url) so the DevTools
  console context selector rows are labeled and selectable.
- Worker lifecycle: the inspector is created on the worker thread
  before the script runs (debug builds only), terminate() kicks a
  paused worker out of its nested pause loop, teardown unregisters the
  target before the isolate is disposed, and frontend reconnects reset
  worker sessions (ordered before the main-isolate Locker so workers
  recover even if the main isolate is paused).
- Make isConnected_ atomic: worker threads now read it while the main
  thread writes the adjacent bitfield flags.

waitForDebuggerOnStart (pause new workers before their first line) is
left as future work; workers announce waitingForDebugger: false.

Ports NativeScript/ios#386 to Android.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b324d733-93fa-46b9-a0f7-bfb0025f9246

📥 Commits

Reviewing files that changed from the base of the PR and between 2656b65 and 13ae257.

📒 Files selected for processing (1)
  • test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp

📝 Walkthrough

Walkthrough

This PR adds worker thread debugging support to the Android V8 inspector. The socket message handler contract shifts from string return to boolean-with-response, worker targets are registered and routed via session identifiers, a new per-worker inspector client integrates with the worker's event loop, and inspector lifecycle is wired into worker bootstrap/teardown with console message forwarding.

Changes

Worker Thread Debugging Infrastructure

Layer / File(s) Summary
Socket message handler contract and Java bridge
test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java, test-app/runtime/src/main/cpp/com_tns_AndroidJsV8Inspector.cpp, test-app/runtime/CMakeLists.txt
Java fast-path refined to send only non-empty responses. JNI bridge and CMakeLists updated to use new bool handleMessageOnSocketThread(message, response) contract.
Main inspector header contracts and atomicity
test-app/runtime/src/main/cpp/JsV8InspectorClient.h, test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (includes, header updates)
Singleton changed to std::atomic<JsV8InspectorClient*>, includes added for worker routing support, WorkerTarget struct and tracking map introduced, connection state upgraded to std::atomic<bool>.
Socket-thread worker routing and command handling
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (message handler, routing helpers)
Socket-thread handler parses JSON and routes by sessionId, handles Network.loadNetworkResource/IO.read/IO.close/Target.* commands with session-aware replies, implements Debugger.pause interrupt logic aware of nested pause loops, and provides worker target registration/announcement helpers.
Connection disconnect refactor and locking
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (disconnect)
Worker sessions reset first, JNI cleanup synchronized under connectionMutex_, and inspector session recreated.
Frontend send safety and callback updates
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (SendToFrontend, callbacks)
New SendToFrontend snapshots JNI refs under connectionMutex_, rewrites source map URLs, and clears exceptions on failure. Singleton access and event/console callbacks refactored for atomic semantics and connection state checking. Console routing separates workers via WorkerWrapper and main-thread via V8 inspector message storage.
Worker inspector client header contract
test-app/runtime/src/main/cpp/WorkerInspectorClient.h
Declares WorkerInspectorClient implementing V8InspectorClient and V8Inspector::Channel with cross-thread messaging, pause control, termination, console logging, and session reset support.
Worker inspector client implementation
test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp
Implements per-worker inspector with eventfd-backed looper integration, message queueing/draining, protocol dispatch with session reset coordination, pause-loop message pump with interruption scheduling, flat-session protocol wrapping with sessionId prefix, and V8 stack-trace-based console message storage.
WorkerWrapper lifecycle wiring and console forwarding
test-app/runtime/src/main/cpp/WorkerWrapper.h, test-app/runtime/src/main/cpp/WorkerWrapper.cpp
Debug-only inspector creation during bootstrap with file-based URL, termination notification under lock to release paused workers, and destruction before runtime teardown. Header exposes public console-log forwarding and private lifecycle methods with synchronization primitives.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • NativeScript/android#1969: Both PRs touch the socket-thread inspector command handling, with #1969 adding Network.loadNetworkResource/IO.read/IO.close server-side support and this PR refactoring the message handler contract to bool-plus-out-parameter.
  • NativeScript/android#1967: Both PRs refine the handleMessageOnSocketThread fast-path behavior in AndroidJsV8Inspector and the native message routing semantics.

Suggested reviewers

  • rigor789
  • NathanWalker

🐰 Worker threads now share the debugging dream,
DevTools sees each isolate's shimmering stream,
Flat sessions, pause loops, and messages flow free,
Console logs bloom where the workers decree!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(inspector): attach Chrome DevTools to Web Worker isolates' directly and clearly describes the main feature being implemented—adding DevTools support for Web Workers—matching the primary objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (1)

514-524: 🩺 Stability & Availability | 🏗️ Heavy lift

Avoid socket writes while holding workerTargetsMutex_.

Line 514+ / Line 542+ / Line 571+ / Line 585+ call SendToFrontend(...) while the registry mutex is held. If socket send blocks, worker register/unregister/routing can stall behind that lock. Snapshot payloads under lock, then send after unlocking.

Also applies to: 542-559, 571-577, 585-605

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp` around lines 514 -
524, You're holding workerTargetsMutex_ while performing socket writes via
SendToFrontend/JsonDump (e.g., in the branch handling missing sessionId and
other branches that currently call SendToFrontend while locked), which can block
other registry operations; instead, inside the critical section (while holding
workerTargetsMutex_) construct and copy the JSON payload/string you intend to
send and then release the lock, and only after unlocking call
SendToFrontend(JsonDump(...)) or SendToFrontend(copiedPayload). Apply this
pattern for all occurrences that call SendToFrontend while workerTargetsMutex_
is held (search for SendToFrontend usages near workerTargets_ handling: the
missing-session branch, and the other locations noted in the comment).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp`:
- Around line 814-821: GetInstance currently does an atomic load->new->store
which can race and leak if two threads concurrently see null; change
JsV8InspectorClient::GetInstance to perform a race-free singleton init by using
either std::call_once with a std::once_flag or an atomic compare_exchange loop
on the static instance so only one caller constructs the object and others reuse
it; keep the creation using Runtime::GetRuntime(0)->GetIsolate() and ensure
GetInstanceIfCreated remains unchanged, and verify callers like
Java_com_tns_AndroidJsV8Inspector_handleMessageOnSocketThread use the fixed
GetInstance to avoid double construction.

In `@test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp`:
- Around line 205-239: The pauseTerminated_ flag is subject to a data race
because it is written from NotifyTerminating() (called from another thread)
while being read/modified in runMessageLoopOnPause() and
quitMessageLoopOnPause(); change the pauseTerminated_ member in
WorkerInspectorClient to std::atomic<bool> and update all accesses to use atomic
operations with appropriate memory ordering (e.g., load(memory_order_acquire)
when reading in runMessageLoopOnPause and store(memory_order_release) when
setting in NotifyTerminating and quitMessageLoopOnPause) so reads/writes are
synchronized across threads.

---

Nitpick comments:
In `@test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp`:
- Around line 514-524: You're holding workerTargetsMutex_ while performing
socket writes via SendToFrontend/JsonDump (e.g., in the branch handling missing
sessionId and other branches that currently call SendToFrontend while locked),
which can block other registry operations; instead, inside the critical section
(while holding workerTargetsMutex_) construct and copy the JSON payload/string
you intend to send and then release the lock, and only after unlocking call
SendToFrontend(JsonDump(...)) or SendToFrontend(copiedPayload). Apply this
pattern for all occurrences that call SendToFrontend while workerTargetsMutex_
is held (search for SendToFrontend usages near workerTargets_ handling: the
missing-session branch, and the other locations noted in the comment).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a62cec75-6809-44d1-af66-cbcd3dff76e0

📥 Commits

Reviewing files that changed from the base of the PR and between a84d3c7 and 2656b65.

📒 Files selected for processing (9)
  • test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java
  • test-app/runtime/CMakeLists.txt
  • test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp
  • test-app/runtime/src/main/cpp/JsV8InspectorClient.h
  • test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp
  • test-app/runtime/src/main/cpp/WorkerInspectorClient.h
  • test-app/runtime/src/main/cpp/WorkerWrapper.cpp
  • test-app/runtime/src/main/cpp/WorkerWrapper.h
  • test-app/runtime/src/main/cpp/com_tns_AndroidJsV8Inspector.cpp

Comment thread test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp
Comment on lines +205 to +239
void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
return;
}
runningPauseLoop_.store(true, std::memory_order_release);
pauseTerminated_ = false;

while (!pauseTerminated_ && !dying_) {
std::string message = this->PopMessage();
bool shouldWait = message.empty();
if (!shouldWait) {
this->DispatchOne(message);
}

while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) {
}

if (shouldWait && !pauseTerminated_ && !dying_) {
std::unique_lock<std::mutex> lock(messageArrivedMutex_);
messageArrived_.wait_for(lock, std::chrono::milliseconds(1));
}
}

runningPauseLoop_.store(false, std::memory_order_release);
}

void WorkerInspectorClient::quitMessageLoopOnPause() {
pauseTerminated_ = true;
}

void WorkerInspectorClient::NotifyTerminating() {
dying_ = true;
pauseTerminated_ = true;
messageArrived_.notify_all();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Data race on pauseTerminated_ flag.

pauseTerminated_ is written from a different thread in NotifyTerminating() (called via WorkerWrapper::Terminate() from the parent thread) while it may be read concurrently in runMessageLoopOnPause() on the worker thread. This needs to be an std::atomic<bool> with appropriate memory ordering.

🔒 Proposed fix: make pauseTerminated_ atomic

In the header, change:

- bool pauseTerminated_ = false;
+ std::atomic<bool> pauseTerminated_{false};

Then update usages with appropriate memory ordering:

 void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
     if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
         return;
     }
     runningPauseLoop_.store(true, std::memory_order_release);
-    pauseTerminated_ = false;
+    pauseTerminated_.store(false, std::memory_order_relaxed);
 
-    while (!pauseTerminated_ && !dying_) {
+    while (!pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
         // ...
-        if (shouldWait && !pauseTerminated_ && !dying_) {
+        if (shouldWait && !pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
             // ...
         }
     }
     // ...
 }
 
 void WorkerInspectorClient::quitMessageLoopOnPause() {
-    pauseTerminated_ = true;
+    pauseTerminated_.store(true, std::memory_order_release);
 }
 
 void WorkerInspectorClient::NotifyTerminating() {
     dying_ = true;
-    pauseTerminated_ = true;
+    pauseTerminated_.store(true, std::memory_order_release);
     messageArrived_.notify_all();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
return;
}
runningPauseLoop_.store(true, std::memory_order_release);
pauseTerminated_ = false;
while (!pauseTerminated_ && !dying_) {
std::string message = this->PopMessage();
bool shouldWait = message.empty();
if (!shouldWait) {
this->DispatchOne(message);
}
while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) {
}
if (shouldWait && !pauseTerminated_ && !dying_) {
std::unique_lock<std::mutex> lock(messageArrivedMutex_);
messageArrived_.wait_for(lock, std::chrono::milliseconds(1));
}
}
runningPauseLoop_.store(false, std::memory_order_release);
}
void WorkerInspectorClient::quitMessageLoopOnPause() {
pauseTerminated_ = true;
}
void WorkerInspectorClient::NotifyTerminating() {
dying_ = true;
pauseTerminated_ = true;
messageArrived_.notify_all();
}
void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
return;
}
runningPauseLoop_.store(true, std::memory_order_release);
pauseTerminated_.store(false, std::memory_order_relaxed);
while (!pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
std::string message = this->PopMessage();
bool shouldWait = message.empty();
if (!shouldWait) {
this->DispatchOne(message);
}
while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) {
}
if (shouldWait && !pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
std::unique_lock<std::mutex> lock(messageArrivedMutex_);
messageArrived_.wait_for(lock, std::chrono::milliseconds(1));
}
}
runningPauseLoop_.store(false, std::memory_order_release);
}
void WorkerInspectorClient::quitMessageLoopOnPause() {
pauseTerminated_.store(true, std::memory_order_release);
}
void WorkerInspectorClient::NotifyTerminating() {
dying_ = true;
pauseTerminated_.store(true, std::memory_order_release);
messageArrived_.notify_all();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp` around lines 205 -
239, The pauseTerminated_ flag is subject to a data race because it is written
from NotifyTerminating() (called from another thread) while being read/modified
in runMessageLoopOnPause() and quitMessageLoopOnPause(); change the
pauseTerminated_ member in WorkerInspectorClient to std::atomic<bool> and update
all accesses to use atomic operations with appropriate memory ordering (e.g.,
load(memory_order_acquire) when reading in runMessageLoopOnPause and
store(memory_order_release) when setting in NotifyTerminating and
quitMessageLoopOnPause) so reads/writes are synchronized across threads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant